Server Send Event(SSE)解決了Long Polling會需要建立多次Request的問題。相比起Long Polling「取得Response後,需要在建立一次Reqeust」。Server Send Event在同一次HTTP連線中,由Server送出多次更新資料。
連線可重複使用。
相比起Long Polling「取得Response後,需要在建立一次Reqeust」。Server Send Event在同一次HTTP連線中,由Server送出多次更新資料。
僅能夠由Server傳送訊息到瀏覽器的單向傳輸。
<!-- www-data/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>即時更新內容 - Sever Send Event</title>
</head>
<body>
<h1 id="content"></h1>
</body>
<script defer type="module">
const DEFAULT_TIMEOUT = 30000 /*ms*/;
const HEATBEAT_INTVAL = 5000 /*ms*/;
const contentEl = document.querySelector('#content');
let evtSource = new EventSource("/connect");
/* recive default message
evtSource.addEventListener('message', async (event) => {
console.log(`recive message: ${event.data}`);
})
*/
/* recive special event - update */
evtSource.addEventListener('update', (event) => {
contentEl.innerText = event.data;
});
</script>
</html>
前端頁面實現也算是簡單的,透過EventSource()
建立Server Send Event來源的連線。透過監聽message
或<event>
來取得更新資訊。
按慣例先引入一些必要的package:
import uvicorn
from typing import TypedDict
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse
from watchdog.events import LoggingEventHandler
from watchdog.observers import Observer
import time
CONTENT_FILE = 'content.txt'
GMT_FORMAT = '%a, %d %b %Y %H:%M:%S GMT'
前端畫面一樣簡簡單單的提供給瀏覽器
@app.get('/index.html', response_class=HTMLResponse)
async def index():
return FileResponse('index.html')
不同的是現在/connect
不是回傳後就直接關閉連線。這次我們要用StreamingResponse
和生成器(generator
),來提供源源不斷的更新資料:
@app.get('/connect')
def connect():
headers = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
}
return StreamingResponse(watch(CONTENT_FILE), headers=headers)
特別注意Content-Type
申明是text/event-stream
,用以告知瀏覽器這是一個事件串流連線,要求瀏覽器以特定方式處理內容。這個特定方式後面會在提到,現在先看一下實現回傳了什麼樣的資料給瀏覽器。
def watch(file_path: str):
message = "event: update\n"
for line in open(file_path):
message += f'data: {line}'
message += '\n'
yield message
# 略後續......
由watch(file)
可以知道/connect
優先回傳了<file>
(也就是content.txt
)的內容。格式基礎如下:
event: update
data: Hello World!
聲明事件類型為update
,並緊跟著一行傳遞資料內容和一行空白行。實際上當content.txt
爲多行的時候,實際格式爲:
event: update
data: Hello World!
data: 你好,世界怎麼跟得上臺灣!
也就是對於單一<event>
,可以跟著多個data:
部分,每個<data>
代表資料的一行,然後同樣跟隨著一行空白行。如果你熟悉HTTP或Mail封包,我想你會和我同樣覺得這樣的格式設計很像HTTP Header的部分。
接著同樣去監看content.txt
檔案是否有被修改過,若被修改過就在傳一次同樣格式的訊息出去。
def watch(file_path: str):
# 前略...
file_stat: FileStat = { "modified": False }
observer = Observer()
observer.schedule(WatchDogEvent(file_stat), CONTENT_FILE, recursive=False)
observer.start()
try:
while True:
if file_stat["modified"]:
message = "event: update\n"
for line in open(file_path):
message += f'data: {line}'
message += '\n'
yield message
file_stat["modified"] = False
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.stop()
現在可以嘗試啓動服務器看看
uvicorn app:app
開啓瀏覽器瀏覽 http://localhost:8000/index.html
上面DEMO示例所傳送的資料格式是:
event: update
data: Hello World!
data: 你好,世界怎麼跟得上臺灣!
實際上<event>
的申請是可選的,也就不需要添加。預設會是message
事件,也就是說下面格式:
data: Hello World!
data: 你好,世界怎麼跟得上臺灣!
等同於
event: message
data: Hello World!
data: 你好,世界怎麼跟得上臺灣!
那麼在瀏覽器接受訊息的時候就應該寫成:
evtSource.addEventListener('message', async (event) => {
console.log(`recive message: ${event.data}`);
})
如同JavaScript、CSS、HTML都有定義註解格式一樣,直接以:
開頭的將被當時註釋忽略。譬如:
: this is a test stream
雖然註解幾乎要使用其他網路工具才可以檢視到,但是定期發送一個訊息作爲heatbeat有助於維持雙方的連線。
註解將有助於防止連線逾時;伺服器端可以定時發送註解以維持連線活著。[^1]
不過我嘗試直接傳出其他格式內容,似乎也會被忽略處理。
你可以透過設定<EventSource>
的onerror
來處理在瀏覽器中的錯誤:
evtSource.onerror = (err) => {
console.error("EventSource failed:", err);
evtSource.close();
}
比如在發生錯誤後關閉連線。這裡也就不多做其他示例了。
其實我是優先寫WebSocket的處理方式的,因為原本擔心 HTTP/2 的 Server Push 和 Server Send Event(SSE) 指的是同一件事情,畢竟兩者從概觀上行為非常相像。不過後來了解到是不同東西,我找到一個 Server Push 的 DEMO 頁面。
可以見得在 Google Chrome 網頁瀏覽器下,有部分資源是由 Server Push 推送而來。 在 Mozilla Firefox Browser 觀察不到這樣的結果,並不確定瀏覽器的支援程度。
此外,在未來 Google Chrome 的版本中,也有可能不支援 Server Push,Chrome將移除不實用的HTTP/2伺服器推送功能。
也因此原本計劃將其放在「你可能不知道的即時更新方案」中倒數第二,所以優先寫了 WebSocket 的內容,但其實可以看到Lab環境建立起的DEMO效果是與Server Push不同的。所以現在感覺起來 WebSocket 的示例似乎寫複雜了?。
然後關於 Heartbeat 這件事情啊,如果你是使用高級框架,那麼通常框架幫你做掉了,也就不需要自己做。但是這次這個 DEMO 並沒有使用什麼高層次的 API ,初衷就是希望盡可能接近原生 API 。 所以提醒一下,儘管我沒有做,但實際使用時,最好每隔固定時間,就由 Server 送出一跳簡單訊息作為 Heartbeat ,也是為了確保連線正常。更多情況使用時, API 伺服器前可能還有一層 API Gateway 作為反向代理。長時間沒有訊息傳遞,連線可能被關閉。
[^1]: MDN-Server Send Event
本文同時發表於我的隨筆